Java 线程安全与锁优化

1.线程安全

当我们谈线程安全的时候,我们谈些什么?

先看看「线程安全」的定义:

多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

1.1 Java 语言中的线程安全

可将 Java 语言中的各种操作共享的数据分为以下 5 类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1. 不可变

只要一个不可变对象被正确地构建出来(没有 this 引用逃逸的情况),那么其外部的可见状态永远也不会改变。

final + 基本数据类型,则该变量为不可变变量
final + 对象,无法保证对象是不可变的

如何保证对象的行为不会对其状态产生任何影响?最简单的就是将对象中带有状态的变量都声明为 final

Java API 中属于不可变的类有以下几种:

  • String
  • 基础数据类型包装类
  • BigInteger
  • BigDecimal

注意:同为 Number 的原子类 AtomicInteger 和 AtomicLong 则并非不可变的。因为它们都有 set 方法,可以改变对象的状态。

2. 绝对线程安全

绝对线程安全的定义是很严格的。Java API 中标注自己的是线程安全的类,大多数都不是绝对的线程安全。

栗子:Vector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package jvm.ch13;
import java.util.Vector;
public class VectorTest {
private static Vector<Integer> sIntegerVector = new Vector<>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
sIntegerVector.add(i);
}
Thread removeThread = new Thread(() -> {
for (int i = 0; i < sIntegerVector.size(); i++) {
sIntegerVector.remove(i);
}
});
Thread printThread = new Thread(() -> {
for (int i = 0; i < sIntegerVector.size(); i++) {
System.out.println(sIntegerVector.get(i));
}
});
removeThread.start();
printThread.start();
while (Thread.activeCount() > 20) {
}
}
}
}

以上代码可能会报 ArrayIndexOutOfBoundException

因为可能出现一个线程恰好在一个错误的时间删除了一个元素,导致 i 元素已经不再可用,另一个线程访问了 i 元素。(比如:get(i) 进入等待锁期间,remove(i) 刚好执行完了)。

解决:操作时加上锁,使得复合操作变为原子操作。

原子操作与复合操作简介:

  • 原子操作:不可分割的操作,要么成功要么失败。
  • 复合操作:可分割的操作,可能出现数据失效的问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Thread removeThread = new Thread(() -> {
synchronized (sIntegerVector) {//加上锁使之成为原子操作
for (int i = 0; i < sIntegerVector.size(); i++) {
sIntegerVector.remove(i);
}
}
});
Thread printThread = new Thread(() -> {
synchronized (sIntegerVector) {
for (int i = 0; i < sIntegerVector.size(); i++) {
System.out.println(sIntegerVector.get(i));
}
}
});

为什么是使用 Vector 对象作为锁?因为 Vector 内部是使用自带的锁来实现的。

3. 相对线程安全

即通常意义上所讲的线程安全。它需要保证对这个对象单独操作是线程安全的,调用时就不需要做额外的保障措施。

对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性

Java API 中大部分线程安全的类都属于这种类型:

  • Vector,HashTable,Collections.sychronizedCollection()

4. 线程兼容

也就是平常所说的线程不安全类。对象本身并不安全,但是可以通过在调用端正确地使用同步手段来保证对象子啊并发环境中可以安全地使用。

比如:ArrayList、HashKMap

5. 线程对立

在调用端是否使用同步手段都无法再多线程环境下使用。

  • 这种排斥多线程代码很少出现。

线程安全的实现方法

线程安全的实现方法主要有以下几种:互斥同步、非阻塞同步、无同步方案。

1. 互斥同步

「互斥同步」的意思是通过互斥来实现同步。
同步是指在多个线程并发访问共享数据的时候,保证共享数据在同一时刻只能有一个(或者一些,使用信号量的时候)线程使用临界区、互斥量、信号量都是主要的互斥实现方式

最基本的互斥同步实现方法就是使用 synchronized 关键字。synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter、monitorexit 两条指令。根据虚拟机规范的要求,

  • 在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1,
  • 在执行 monitorexit 指令时会将锁计数器减 1,当计数器为 0 时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

上述描述中有两点是需要特别注意的。

  • 首先,synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
  • 其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入

Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间

除了 synchronized 之外,我们还可以使用 java.util.concurrent(简称 J.U.C)包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock 与 synchronized 很相似,他们都具备一样的线程重入特性,不同点表现在代码写法上

  • 一个表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成),
  • 另一个表现为原生语法层面的互斥锁。

相比 synchronized,ReentrantLock 增加了一些高级功能,主要有以下 3 项:

  • 等待可中断,如果持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁
    • 多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;
    • 非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
    • synchronized 中的锁是非公平的,
    • ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
  • 可以绑定多个条件
    • 一个 ReentrantLock 对象可以同时绑定多个 Condition 对象
    • 而在 synchronized 中,锁对象的 wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁。
ReentrantLock 还是 synchronized?

关于性能:Java 1.6 发布之后,synchronized 与 ReentrantLock 的性能基本上是完全持平了。因此,Java 1.6 或以上,性能因素就不再是选择 ReentrantLock 的理由了,虚拟机在未来的性能改进中肯定也会更加偏向于原生的 synchronized。

因此在不需要用到 ReentrantLock 三个特性的情况下,优先考虑使用 synchronized 来进行同步

2. 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。从处理问题的方式上说,互斥同步属于一种悲观的并发策略总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题

基于冲突检测乐观并发策略,通俗地说,就是先进行操作,

  • 如果没有其他线程争用共享数据,那操作就成功了;
  • 如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。

为什么说使用乐观并发策略需要 “硬件指令集的发展” 才能进行呢?

  • 因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证呢?如果这里再使用互斥同步来保证就失去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成
  • 这类指令常用的有:
    • 测试并设置(Test-and-Set)。
    • 获取并增加(Fetch-and-Increment)。
    • 交换(Swap)。
    • ==比较并交换(Compare-and-Swap,下文称 CAS==)。
    • 加载链接/条件存储(Load-Linked/Store-Conditional,下文称 LL/SC)。

Java 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()compareAndSwapLong() 等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令。

CAS 指令需要有 3 个操作数,分别是内存位置(在 Java 中可以简单理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,否则它就不执行更新,但是无论是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作

Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的 Class 才能访问它),因此,如果不采用反射手段,我们只能通过其他的 Java API 来间接使用它。

  • 如 J.U.C(java.util.concurrent) 包里面的整数原子类,其中的 compareAndSet()和 getAndIncrement()等方法都使用了 Unsafe 类的 CAS 操作。
1
2
3
4
5
6
7
8
public final int incrementAndGet(){
for(;;){
int current = get();
int next = current+1
if(compareAndSet(current,next))
return next;
}
}

CAS 存在的问题:

  • 无法涵盖互斥同步的所有使用场景
  • 存在==ABA 问题==,值改了但是自己却不知道。
    • 怎么解决?
      • 加入引用计数。
      • 加入修改记录

可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码的一些共同的特征

  • 不依赖存储在堆上的数据和公用的系统资源、
  • 用到的状态量都由参数中传入
  • 不调用非可重入的方法等。

判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,也就是说只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

3. 无同步方案

一个数据的可见范围局限在同一个线程之内, 不会存在多线程竞争问题,也就不需要进行同步了。

如果一个变量要被某个线程独享,Java 中可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 值对中找回对应的本地线程变量。

2. Java 中锁的分类与优化

可重入锁

又称为「递归锁」,指的是当同一个线程的外层方法获取锁时,进入内层方法会自动获取锁。

可重入锁的一个好处就是可以在一定程度上避免死锁。

1
2
3
4
5
6
synchronized void setA() throws Exception {
Thread.sleep(1000);
synchronized(this) {
Thread.sleep(1000);
}
}

独享(占)锁/共享锁

根据能够同时被多少线程持有来区分。

  • 独享锁就是指该锁一次仅能被一个线程所持有。
  • 共享锁可以同时被多个线程持有。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法互斥锁/读写锁就是具体的实现

  • 互斥锁:在 Java 中的具体就是 ReentrantLock
  • 读写锁:在 Java 中的具体实现就是 ReadWriteLock。其读锁是共享锁,其写锁是独享锁。
    • 读锁的共享可保证高效并发。
    • 读写、写读、写写的过程是互斥的

独享锁与共享锁也是使用 AQS 来实现的。通过实现不同的方法来实现独享或者共享。

乐观锁/悲观锁

并不是指具体类型的锁,而是指看待并发同步的态度

悲观锁采用一种悲观的并发策略总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题
所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁不能解决脏读的问题

二者之间如何选择选择?

首先要弄清楚,事实是悲观的还是乐观的

假如你的资源竞争很激烈,并且无法共享的话,乐观锁不过是让大量请求的希望落空罢了。——如果事实是悲观的,但是采用了乐观锁,那就只会导致大量请求落空罢了。

假如你的资源没什么竞争(这个和并发高低没必然的关联,业务的影响更大),那悲观锁意味着不必要地加锁。如果原本是可共享的资源(比如资源支持多个只读方),那么悲观锁意味着失去原本的可以使用的时间。——如果事实是乐观的,但是使用了悲观锁,那么就损失一些本来可以使用的时间。

再详细点可以从以下几个方面来判断:

  1. 响应速度:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。
  2. 冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率,如果冲突频率大,乐观锁会需要多次重试才能成功,代价比较大。
  3. 重试代价:如果重试代价大,建议采用悲观锁。

以上内容参考自高并发下悲观锁与乐观锁的选择问题

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为 Segment,它即类似于 HashMap(Java7 与 Java8 中 HashMap 的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表;同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)
当需要 put 元素的时候,并不是对整个 hashmap 进行加锁,而是先通过 hashcode 来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程 put 的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计 size 的时候,可就是获取 hashmap 全局信息的时候,就需要获取所有的分段锁才能统计。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized 的。在 Java1.5 通过引入锁升级的机制来实现高效的Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的

偏向锁:当一段同步代码总是被一个线程访问的时候, 那么该线程就会自动获取锁,降低获取锁的代价。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。偏向锁的“偏”,意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

轻量级锁:当锁是偏向锁时(因为经常被一个线程访问),当另外一个线程需要访问相应的同步代码段时,偏向锁会升级为轻量级锁,其他线程会通过自旋的方式尝试去获取锁,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

重量级锁:自旋达到一定次数之后,就会膨胀为重量级锁。重量级锁会使其他申请线程进入阻塞状态。

错误观点:可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。具体可以参考下这篇文章

锁的升级:

lock update

偏向锁的实现

假设当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中,

  • 如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如 Locking、Unlocking 及对 Mark Word 的 Update 等)。
  • 当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
  • 根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,
  • 后续的同步操作就如上面介绍的轻量级锁那样执行。

lock

图片参考自聊聊并发(二)——Java SE1.6中的Synchronized

轻量级锁

轻量级锁的加锁过程

在代码进入同步块的时候,

  • 如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图 13-3 所示。
  • 然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。
    • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后 2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
    • 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧
      • 如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,
    • 否则说明这个锁对象已经被其他线程抢占了。
      • 如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

cas

light weight

图片参考自聊聊并发(二)——Java SE1.6中的Synchronized

轻量级锁的解锁过程

解锁过程也是通过 CAS 操作来进行的

  • 如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来
    • 如果替换成功,整个同步过程就完成了。
    • 如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。(如何唤醒? notify()

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。

  • 如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,
  • 如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在==有竞争的情况下,轻量级锁会比传统的重量级锁更慢==。

锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗 CPU 追求响应时间,锁占用时间很短
重量级锁 线程竞争不使用自旋,不会消耗 CPU 线程阻塞,响应时间缓慢 追求吞吐量,锁占用时间较长

自旋锁

在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。
典型的自旋锁实现的例子,可以参考自旋锁的实现

如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。这样可以避免线程挂起和恢复(挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力),从而降低性能开销。

  • 自旋次数的默认值是 10 次,用户可以使用参数-XX:PreBlockSpin 来更改。

在 Java 1.6 中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

锁消除的主要判定依据来源于逃逸分析的数据支持(第 11 章已经讲解过逃逸分析技术),如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

举个栗子:

1
2
3
4
5
6
7
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

每个 StringBuffer.append()方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString()方法内部。sb 的所有引用永远不会“逃逸”到 concatString()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为使得需要同步的操作数量尽可能变小

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗

1
2
3
4
5
6
7
public String concatString(String s1,String s2,String s3){
StringBuffer sb=new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

上面连续的 append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上述代码为例,就是扩展到第一个 append()操作之前直至最后一个 append()操作之后,这样只需要加锁一次就可以了。

参考资料与学习资源推荐

如果本文中存在不正确的说法,请提出,共同讨论,共同进步,谢谢!

Show Comments
0%